import _ from 'lodash'
import crypto from 'crypto'
import path from 'path'
import fs from 'fs'
import wait from 'timers-ext/promise/sleep.js'
import validate from './lib/validate.js'
import filesize from '../../utils/filesize.js'
import ServerlessError from '../../serverless-error.js'

class AwsDeployFunction {
  constructor(serverless, options, pluginUtils) {
    this.serverless = serverless
    this.logger = pluginUtils.log
    this.loggerStyle = pluginUtils.style
    this.progress = pluginUtils.progress
    this.options = options || {}
    this.packagePath =
      this.options.package ||
      this.serverless.service.package.path ||
      path.join(this.serverless.serviceDir || '.', '.serverless')
    this.provider = this.serverless.getProvider('aws')

    this.shouldEnsureFunctionState = false

    Object.assign(this, validate)

    this.hooks = {
      initialize: () => {
        const commandName = this.serverless.processedInput.commands.join(' ')
        if (commandName !== 'deploy function') return
        this.logger.notice(
          `Deploying function "${
            this.options.function
          }" to stage "${this.serverless
            .getProvider('aws')
            .getStage()}" ${this.loggerStyle.aside(
            `(${this.serverless.getProvider('aws').getRegion()})`,
          )}`,
        )
      },
      'before:deploy:function:initialize': () =>
        this.progress.notice('Validating', { isMainEvent: true }),
      'deploy:function:initialize': async () => {
        this.logger.debug('validating')
        await this.validate()
        this.logger.debug('checking if function exists')
        await this.checkIfFunctionExists()
        this.logger.debug('checking if function changed')
        this.checkIfFunctionChangesBetweenImageAndHandler()
      },

      'before:deploy:function:packageFunction': () =>
        this.progress.notice('Retrieving function info'),
      'deploy:function:packageFunction': async () =>
        this.serverless.pluginManager.spawn('package:function'),

      'before:deploy:function:deploy': () =>
        this.progress.notice('Packaging', { isMainEvent: true }),
      'deploy:function:deploy': async () => {
        if (!this.options['update-config']) {
          this.logger.debug('deploying function code')
          await this.deployFunction()
        }
        await this.updateFunctionConfiguration()
        if (this.shouldEnsureFunctionState) {
          await this.ensureFunctionState()
        }
        await this.serverless.pluginManager.spawn('aws:common:cleanupTempDir')
      },
    }
  }

  async checkIfFunctionExists() {
    this.progress.notice('Checking for changes')

    // check if the function exists in the service
    this.options.functionObj = this.serverless.service.getFunction(
      this.options.function,
    )

    // check if function exists on AWS
    const params = {
      FunctionName: this.options.functionObj.name,
    }

    const result = await (async () => {
      try {
        return await this.provider.request('Lambda', 'getFunction', params)
      } catch (error) {
        if (
          _.get(error, 'providerError.code') === 'ResourceNotFoundException'
        ) {
          const errorMessage = [
            `The function "${this.options.function}" you want to update is not yet deployed.`,
            ' Please run "serverless deploy" to deploy your service.',
            ' After that you can redeploy your services functions with the',
            ' "serverless deploy function" command.',
          ].join('')
          throw new ServerlessError(errorMessage, 'FUNCTION_NOT_YET_DEPLOYED')
        }
        throw error
      }
    })()

    if (result) this.serverless.service.provider.remoteFunctionData = result
  }

  checkIfFunctionChangesBetweenImageAndHandler() {
    const functionObject = this.serverless.service.getFunction(
      this.options.function,
    )
    const remoteFunctionPackageType =
      this.serverless.service.provider.remoteFunctionData.Configuration
        .PackageType

    if (functionObject.handler && remoteFunctionPackageType === 'Image') {
      throw new ServerlessError(
        `The function "${this.options.function}" you want to update with handler was previously packaged as an image. Please run "serverless deploy" to ensure consistent deploy.`,
        'DEPLOY_FUNCTION_CHANGE_BETWEEN_HANDLER_AND_IMAGE_ERROR',
      )
    }

    if (functionObject.image && remoteFunctionPackageType === 'Zip') {
      throw new ServerlessError(
        `The function "${this.options.function}" you want to update with image was previously packaged as zip file. Please run "serverless deploy" to ensure consistent deploy.`,
        'DEPLOY_FUNCTION_CHANGE_BETWEEN_HANDLER_AND_IMAGE_ERROR',
      )
    }
  }

  async normalizeArnRole(role) {
    if (typeof role === 'string') {
      if (role.indexOf(':') !== -1) {
        return role
      }

      const roleResource = this.serverless.service.resources.Resources[role]

      if (roleResource.Type !== 'AWS::IAM::Role') {
        throw new ServerlessError(
          'Provided resource is not IAM Role',
          'ROLE_REFERENCES_NON_AWS_IAM_ROLE',
        )
      }
      const roleProperties = roleResource.Properties
      if (!roleProperties.RoleName) {
        throw new ServerlessError(
          'Role resource missing RoleName property',
          'MISSING_ROLENAME_FOR_ROLE',
        )
      }
      const compiledFullRoleName = `${roleProperties.Path || '/'}${
        roleProperties.RoleName
      }`

      const result = await this.provider.getAccountInfo()
      return `arn:${result.partition}:iam::${result.accountId}:role${compiledFullRoleName}`
    }

    const data = await this.provider.request('IAM', 'getRole', {
      RoleName: role['Fn::GetAtt'][0],
    })
    return data.Arn
  }

  async ensureFunctionState() {
    this.options.functionObj = this.serverless.service.getFunction(
      this.options.function,
    )
    const params = {
      FunctionName: this.options.functionObj.name,
    }
    const startTime = Date.now()

    const callWithRetry = async () => {
      const result = await this.provider.request(
        'Lambda',
        'getFunction',
        params,
      )
      if (
        result &&
        result.Configuration.State === 'Active' &&
        result.Configuration.LastUpdateStatus === 'Successful'
      ) {
        return
      }
      const didOneMinutePass = Date.now() - startTime > 60 * 1000
      if (didOneMinutePass) {
        throw new ServerlessError(
          'Ensuring function state timed out. Please try to deploy your function once again.',
          'DEPLOY_FUNCTION_ENSURE_STATE_TIMED_OUT',
        )
      }
      this.logger.info(
        `Retrying ensure function state for function: ${this.options.function}.`,
      )
      await wait(500)
      await callWithRetry()
    }

    await callWithRetry()
  }

  async callUpdateFunctionConfiguration(params) {
    this.logger.debug('deploying function configuration changes')

    const startTime = Date.now()

    const callWithRetry = async () => {
      try {
        await this.provider.request(
          'Lambda',
          'updateFunctionConfiguration',
          params,
        )
      } catch (err) {
        const didOneMinutePass = Date.now() - startTime > 60 * 1000

        if (
          err.providerError &&
          err.providerError.code === 'ResourceConflictException'
        ) {
          if (didOneMinutePass) {
            throw new ServerlessError(
              'Retry timed out. Please try to deploy your function once again.',
              'DEPLOY_FUNCTION_CONFIGURATION_UPDATE_TIMED_OUT',
            )
          }
          this.logger.info(
            `Retrying configuration update for function: ${this.options.function}. Reason: ${err.message}`,
          )
          await wait(1000)
          await callWithRetry()
        } else {
          throw err
        }
      }
    }
    await callWithRetry()
  }

  async updateFunctionConfiguration() {
    const functionObj = this.options.functionObj
    const providerObj = this.serverless.service.provider
    const remoteFunctionConfiguration =
      this.serverless.service.provider.remoteFunctionData.Configuration
    const params = {
      FunctionName: functionObj.name,
    }

    const kmsKeyArn = functionObj.kmsKeyArn || providerObj.kmsKeyArn

    if (kmsKeyArn) {
      params.KMSKeyArn = kmsKeyArn
    }

    if (
      params.KMSKeyArn &&
      params.KMSKeyArn === remoteFunctionConfiguration.KMSKeyArn
    ) {
      delete params.KMSKeyArn
    }

    if (functionObj.snapStart) {
      params.SnapStart = {
        ApplyOn: 'PublishedVersions',
      }
    }

    if (
      functionObj.description &&
      functionObj.description !== remoteFunctionConfiguration.Description
    ) {
      params.Description = functionObj.description
    }

    if (
      functionObj.handler &&
      functionObj.handler !== remoteFunctionConfiguration.Handler
    ) {
      params.Handler = functionObj.handler
    }

    if (functionObj.memorySize) {
      params.MemorySize = functionObj.memorySize
    } else if (providerObj.memorySize) {
      params.MemorySize = providerObj.memorySize
    }

    if (
      params.MemorySize &&
      params.MemorySize === remoteFunctionConfiguration.MemorySize
    ) {
      delete params.MemorySize
    }

    if (functionObj.timeout) {
      params.Timeout = functionObj.timeout
    } else if (providerObj.timeout) {
      params.Timeout = providerObj.timeout
    }

    if (
      params.Timeout &&
      params.Timeout === remoteFunctionConfiguration.Timeout
    ) {
      delete params.Timeout
    }

    // Do not set the runtime if the PackageType is Image
    // "Specifying a runtime results in an error if you're deploying a function using a container image."
    // https://docs.aws.amazon.com/lambda/latest/api/API_CreateFunction.html#lambda-CreateFunction-request-Runtime
    if (remoteFunctionConfiguration.PackageType !== 'Image') {
      const runtime = this.provider.getRuntime(functionObj.runtime)

      if (runtime !== remoteFunctionConfiguration.Runtime) {
        params.Runtime = runtime
      }
    }

    // Check if we have remotely managed layers and add them to the update call
    // if they exist in the remote function configuration.
    const isConsoleSdkLayerArn = RegExp.prototype.test.bind(
      /(?:177335420605|321667558080):layer:sls-/u,
    )
    const serverlessConsoleLayerArns = (
      remoteFunctionConfiguration.Layers || []
    )
      .filter(({ Arn: arn }) => isConsoleSdkLayerArn(arn))
      .map(({ Arn }) => Arn)
    const hasServerlessConsoleLayers = serverlessConsoleLayerArns.length > 0
    if (!functionObj.layers || !functionObj.layers.some(_.isObject)) {
      // We need to initialize to an empty array so if a layer is removed
      // we will send an empty Layers array in the update call to remove any layers.
      // If there are no layers in the remove config this property will be set to undefined anyway.
      params.Layers = functionObj.layers || providerObj.layers || []

      if (!remoteFunctionConfiguration.Layers) {
        remoteFunctionConfiguration.Layers = []
      }

      if (hasServerlessConsoleLayers) {
        for (const layer of serverlessConsoleLayerArns) {
          if (!params.Layers.includes(layer)) {
            params.Layers.push(layer)
          }
        }
      }

      // Do not attach layers to the update call if the layers did not change.
      if (
        params.Layers &&
        remoteFunctionConfiguration.Layers &&
        _.isEqual(
          new Set(params.Layers),
          new Set(remoteFunctionConfiguration.Layers.map((layer) => layer.Arn)),
        )
      ) {
        delete params.Layers
      }
    }

    if (
      functionObj.onError &&
      !_.isObject(functionObj.onError) &&
      _.get(remoteFunctionConfiguration, 'DeadLetterConfig.TargetArn', null) !==
        functionObj.onError
    ) {
      params.DeadLetterConfig = {
        TargetArn: functionObj.onError,
      }
    }

    // Add empty environment object if it does not exist
    // so when we do the comparison below it will be equal to an empty object
    params.Environment = {
      Variables: {},
    }
    if (!remoteFunctionConfiguration.Environment) {
      remoteFunctionConfiguration.Environment = {
        Variables: {},
      }
    }

    if (functionObj.environment || providerObj.environment) {
      params.Environment.Variables = Object.assign(
        {},
        providerObj.environment,
        functionObj.environment,
      )
    }
    if (
      Object.values(params.Environment.Variables).some((value) =>
        _.isObject(value),
      )
    ) {
      delete params.Environment
    } else {
      Object.keys(params.Environment.Variables).forEach((key) => {
        // taken from the bash man pages
        if (!key.match(/^[A-Za-z_][a-zA-Z0-9_]*$/)) {
          const errorMessage = 'Invalid characters in environment variable'
          throw new ServerlessError(
            errorMessage,
            'DEPLOY_FUNCTION_INVALID_ENV_VARIABLE',
          )
        }

        if (params.Environment.Variables[key] != null) {
          params.Environment.Variables[key] = String(
            params.Environment.Variables[key],
          )
        }
      })
    }
    // If we detected remotely managed layers, we need to add the environment variables
    // that are managed by the Serverless Console to the update call so they do not get removed.
    if (params.Environment && hasServerlessConsoleLayers) {
      const consoleEnvironmentVariableNames = [
        'AWS_LAMBDA_EXEC_WRAPPER',
        'SLS_ORG_ID',
        'SLS_DEV_MODE_ORG_ID',
        'SLS_DEV_TOKEN',
        'SERVERLESS_PLATFORM_STAGE',
      ]
      const remoteVariables = remoteFunctionConfiguration.Environment.Variables
      const localVariables = params.Environment.Variables
      for (const variableName of consoleEnvironmentVariableNames) {
        if (remoteVariables[variableName] && !localVariables[variableName]) {
          localVariables[variableName] = remoteVariables[variableName]
        }
      }
    }

    if (
      params.Environment &&
      remoteFunctionConfiguration.Environment &&
      _.isEqual(
        params.Environment.Variables,
        remoteFunctionConfiguration.Environment.Variables,
      )
    ) {
      delete params.Environment
    }

    if (functionObj.vpc || providerObj.vpc) {
      const vpc = functionObj.vpc || providerObj.vpc
      params.VpcConfig = {}

      if (
        Array.isArray(vpc.securityGroupIds) &&
        !vpc.securityGroupIds.some(_.isObject)
      ) {
        params.VpcConfig.SecurityGroupIds = vpc.securityGroupIds
      }

      if (Array.isArray(vpc.subnetIds) && !vpc.subnetIds.some(_.isObject)) {
        params.VpcConfig.SubnetIds = vpc.subnetIds
      }

      const didVpcChange = () => {
        const remoteConfigToCompare = { SecurityGroupIds: [], SubnetIds: [] }
        if (remoteFunctionConfiguration.VpcConfig) {
          remoteConfigToCompare.SecurityGroupIds = new Set(
            remoteFunctionConfiguration.VpcConfig.SecurityGroupIds || [],
          )
          remoteConfigToCompare.SubnetIds = new Set(
            remoteFunctionConfiguration.VpcConfig.SubnetIds || [],
          )
        }
        const localConfigToCompare = {
          SecurityGroupIds: new Set(params.VpcConfig.SecurityGroupIds || []),
          SubnetIds: new Set(params.VpcConfig.SubnetIds || []),
        }
        return _.isEqual(remoteConfigToCompare, localConfigToCompare)
      }

      if (!Object.keys(params.VpcConfig).length || didVpcChange()) {
        delete params.VpcConfig
      }
    }

    const executionRole = this.provider.getCustomExecutionRole(functionObj)
    if (executionRole) {
      params.Role = await this.normalizeArnRole(executionRole)
    }

    if (params.Role === remoteFunctionConfiguration.Role) {
      delete params.Role
    }

    if (functionObj.image) {
      const imageConfig = {}
      if (_.isObject(functionObj.image)) {
        if (functionObj.image.command) {
          imageConfig.Command = functionObj.image.command
        }
        if (functionObj.image.entryPoint) {
          imageConfig.EntryPoint = functionObj.image.entryPoint
        }
        if (functionObj.image.workingDirectory) {
          imageConfig.WorkingDirectory = functionObj.image.workingDirectory
        }
      }

      if (
        !_.isEqual(
          imageConfig,
          _.get(
            remoteFunctionConfiguration,
            'ImageConfigResponse.ImageConfig',
            {},
          ),
        )
      ) {
        params.ImageConfig = imageConfig
      }
    }

    if (!Object.keys(_.omit(params, 'FunctionName')).length) {
      const noticeMessage = [
        'Function configuration did not change, so the update was skipped.',
        ' If you made changes to the service configuration and expected them to be deployed,',
        ' this most likely means that they can only be applied with a full service deployment: "serverless deploy".',
      ].join('')
      this.logger.aside(
        `${noticeMessage} ${this.loggerStyle.aside(
          `(${Math.floor(
            (Date.now() - this.serverless.pluginManager.commandRunStartTime) /
              1000,
          )}s)`,
        )}`,
      )
      return
    }

    this.progress.notice('Updating function configuration', {
      isMainEvent: true,
    })

    await this.callUpdateFunctionConfiguration(params)
    this.shouldEnsureFunctionState = true
    if (this.options['update-config']) this.logger.notice()
    this.logger.success(
      `Function configuration updated ${this.loggerStyle.aside(
        `(${Math.floor(
          (Date.now() - this.serverless.pluginManager.commandRunStartTime) /
            1000,
        )}s)`,
      )}\n`,
    )
  }

  async deployFunction() {
    const functionObject = this.serverless.service.getFunction(
      this.options.function,
    )
    const params = {
      FunctionName: this.options.functionObj.name,
    }

    if (functionObject.image) {
      const { functionImageUri, functionImageSha } =
        await this.provider.resolveImageUriAndSha(this.options.function)
      const remoteImageSha =
        this.serverless.service.provider.remoteFunctionData.Configuration
          .CodeSha256
      if (remoteImageSha === functionImageSha && !this.options.force) {
        this.logger.notice(
          `Image did not change. Function deployment skipped. ${this.loggerStyle.aside(
            `(${Math.floor(
              (Date.now() - this.serverless.pluginManager.commandRunStartTime) /
                1000,
            )}s)`,
          )}`,
        )
        return
      }
      params.ImageUri = functionImageUri
    } else {
      const artifactFileName = this.provider.naming.getFunctionArtifactName(
        this.options.function,
      )
      let artifactFilePath = this.serverless.service.package.artifact
        ? path.resolve(
            this.serverless.serviceDir,
            this.serverless.service.package.artifact,
          )
        : path.join(this.packagePath, artifactFileName)
      // check if an artifact is used in function package level
      if (_.get(functionObject, 'package.artifact')) {
        artifactFilePath = functionObject.package.artifact
      }

      const data = fs.readFileSync(artifactFilePath)

      const remoteHash =
        this.serverless.service.provider.remoteFunctionData.Configuration
          .CodeSha256
      const localHash = crypto
        .createHash('sha256')
        .update(data)
        .digest('base64')

      if (remoteHash === localHash && !this.options.force) {
        this.logger.aside(
          `Code did not change. Function deployment skipped. ${this.loggerStyle.aside(
            `(${Math.floor(
              (Date.now() - this.serverless.pluginManager.commandRunStartTime) /
                1000,
            )}s)`,
          )}`,
        )
        return
      }

      params.ZipFile = data

      const stats = fs.statSync(artifactFilePath)
      this.progress.notice(`Uploading (${filesize(stats.size)})`, {
        isMainEvent: true,
      })
    }

    await this.provider.request('Lambda', 'updateFunctionCode', params)
    this.shouldEnsureFunctionState = true
    this.logger.success(
      `Function code deployed ${this.loggerStyle.aside(
        `(${Math.floor(
          (Date.now() - this.serverless.pluginManager.commandRunStartTime) /
            1000,
        )}s)`,
      )}`,
    )
  }
}

export default AwsDeployFunction
